package com.googlecode.coss.common.utils.lang.reflect;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;

/**
 * Helper class that allows for specifying a method to invoke in a declarative
 * fashion, be it static or non-static.
 * <p>
 * Usage: Specify "targetClass"/"targetMethod" or "targetObject"/"targetMethod",
 * optionally specify arguments, prepare the invoker. Afterwards, you may invoke
 * the method any number of times, obtaining the invocation result.
 */
@SuppressWarnings({ "rawtypes", "unchecked" })
public class MethodInvoker {

    private Class    targetClass;

    private Object   targetObject;

    private String   targetMethod;

    private String   staticMethod;

    private Object[] arguments = new Object[0];

    /** The method we will call */
    private Method   methodObject;

    /**
     * Set the target class on which to call the target method. Only necessary
     * when the target method is static; else, a target object needs to be
     * specified anyway.
     * 
     * @see #setTargetObject
     * @see #setTargetMethod
     */
    public void setTargetClass(Class targetClass) {
        this.targetClass = targetClass;
    }

    /**
     * Return the target class on which to call the target method.
     */
    public Class getTargetClass() {
        return this.targetClass;
    }

    /**
     * Set the target object on which to call the target method. Only necessary
     * when the target method is not static; else, a target class is sufficient.
     * 
     * @see #setTargetClass
     * @see #setTargetMethod
     */
    public void setTargetObject(Object targetObject) {
        this.targetObject = targetObject;
        if (targetObject != null) {
            this.targetClass = targetObject.getClass();
        }
    }

    /**
     * Return the target object on which to call the target method.
     */
    public Object getTargetObject() {
        return this.targetObject;
    }

    /**
     * Set the name of the method to be invoked. Refers to either a static
     * method or a non-static method, depending on a target object being set.
     * 
     * @see #setTargetClass
     * @see #setTargetObject
     */
    public void setTargetMethod(String targetMethod) {
        this.targetMethod = targetMethod;
    }

    /**
     * Return the name of the method to be invoked.
     */
    public String getTargetMethod() {
        return this.targetMethod;
    }

    /**
     * Set a fully qualified static method name to invoke, e.g.
     * "example.MyExampleClass.myExampleMethod". Convenient alternative to
     * specifying targetClass and targetMethod.
     * 
     * @see #setTargetClass
     * @see #setTargetMethod
     */
    public void setStaticMethod(String staticMethod) {
        this.staticMethod = staticMethod;
    }

    /**
     * Set arguments for the method invocation. If this property is not set, or
     * the Object array is of length 0, a method with no arguments is assumed.
     */
    public void setArguments(Object[] arguments) {
        this.arguments = (arguments != null ? arguments : new Object[0]);
    }

    /**
     * Return the arguments for the method invocation.
     */
    public Object[] getArguments() {
        return this.arguments;
    }

    /**
     * Prepare the specified method. The method can be invoked any number of
     * times afterwards.
     * 
     * @see #getPreparedMethod
     * @see #invoke
     */
    public void prepare() throws ClassNotFoundException, NoSuchMethodException {
        if (this.staticMethod != null) {
            int lastDotIndex = this.staticMethod.lastIndexOf('.');
            if (lastDotIndex == -1 || lastDotIndex == this.staticMethod.length()) {
                throw new IllegalArgumentException(
                        "staticMethod must be a fully qualified class plus method name: "
                                + "e.g. 'example.MyExampleClass.myExampleMethod'");
            }
            String className = this.staticMethod.substring(0, lastDotIndex);
            String methodName = this.staticMethod.substring(lastDotIndex + 1);
            this.targetClass = resolveClassName(className);
            this.targetMethod = methodName;
        }

        Class targetClass = getTargetClass();
        String targetMethod = getTargetMethod();
        if (targetClass == null) {
            throw new IllegalArgumentException("Either 'targetClass' or 'targetObject' is required");
        }
        if (targetMethod == null) {
            throw new IllegalArgumentException("Property 'targetMethod' is required");
        }

        Object[] arguments = getArguments();
        Class[] argTypes = new Class[arguments.length];
        for (int i = 0; i < arguments.length; ++i) {
            argTypes[i] = (arguments[i] != null ? arguments[i].getClass() : Object.class);
        }

        // Try to get the exact method first.
        try {
            this.methodObject = targetClass.getMethod(targetMethod, argTypes);
        } catch (NoSuchMethodException ex) {
            // Just rethrow exception if we can't get any match.
            this.methodObject = findMatchingMethod();
            if (this.methodObject == null) {
                throw ex;
            }
        }
    }

    /**
     * Resolve the given class name into a Class.
     * <p>
     * The default implementations uses <code>ClassUtils.forName</code>, using
     * the thread context class loader.
     * 
     * @param className the class name to resolve
     * @return the resolved Class
     * @throws ClassNotFoundException if the class name was invalid
     */
    protected Class resolveClassName(String className) throws ClassNotFoundException {
        return ClassUtils.forName(className, ClassUtils.getDefaultClassLoader());
    }

    /**
     * Find a matching method with the specified name for the specified
     * arguments.
     * 
     * @return a matching method, or <code>null</code> if none
     * @see #getTargetClass()
     * @see #getTargetMethod()
     * @see #getArguments()
     */
    protected Method findMatchingMethod() {
        String targetMethod = getTargetMethod();
        Object[] arguments = getArguments();
        int argCount = arguments.length;

        Method[] candidates = ReflectUtils.getAllDeclaredMethods(getTargetClass());
        int minTypeDiffWeight = Integer.MAX_VALUE;
        Method matchingMethod = null;

        for (Method candidate : candidates) {
            if (candidate.getName().equals(targetMethod)) {
                Class[] paramTypes = candidate.getParameterTypes();
                if (paramTypes.length == argCount) {
                    int typeDiffWeight = getTypeDifferenceWeight(paramTypes, arguments);
                    if (typeDiffWeight < minTypeDiffWeight) {
                        minTypeDiffWeight = typeDiffWeight;
                        matchingMethod = candidate;
                    }
                }
            }
        }

        return matchingMethod;
    }

    /**
     * Return the prepared Method object that will be invoked.
     * <p>
     * Can for example be used to determine the return type.
     * 
     * @return the prepared Method object (never <code>null</code>)
     * @throws IllegalStateException if the invoker hasn't been prepared yet
     * @see #prepare
     * @see #invoke
     */
    public Method getPreparedMethod() throws IllegalStateException {
        if (this.methodObject == null) {
            throw new IllegalStateException(
                    "prepare() must be called prior to invoke() on MethodInvoker");
        }
        return this.methodObject;
    }

    /**
     * Return whether this invoker has been prepared already, i.e. whether it
     * allows access to {@link #getPreparedMethod()} already.
     */
    public boolean isPrepared() {
        return (this.methodObject != null);
    }

    /**
     * Invoke the specified method.
     * <p>
     * The invoker needs to have been prepared before.
     * 
     * @return the object (possibly null) returned by the method invocation, or
     *         <code>null</code> if the method has a void return type
     * @throws InvocationTargetException if the target method threw an exception
     * @throws IllegalAccessException if the target method couldn't be accessed
     * @see #prepare
     */
    public Object invoke() throws InvocationTargetException, IllegalAccessException {
        // In the static case, target will simply be <code>null</code>.
        Object targetObject = getTargetObject();
        Method preparedMethod = getPreparedMethod();
        if (targetObject == null && !Modifier.isStatic(preparedMethod.getModifiers())) {
            throw new IllegalArgumentException(
                    "Target method must not be non-static without a target");
        }
        ReflectUtils.makeAccessible(preparedMethod);
        return preparedMethod.invoke(targetObject, getArguments());
    }

    /**
     * Algorithm that judges the match between the declared parameter types of a
     * candidate method and a specific list of arguments that this method is
     * supposed to be invoked with.
     * <p>
     * Determines a weight that represents the class hierarchy difference
     * between types and arguments. A direct match, i.e. type Integer -> arg of
     * class Integer, does not increase the result - all direct matches means
     * weight 0. A match between type Object and arg of class Integer would
     * increase the weight by 2, due to the superclass 2 steps up in the
     * hierarchy (i.e. Object) being the last one that still matches the
     * required type Object. Type Number and class Integer would increase the
     * weight by 1 accordingly, due to the superclass 1 step up the hierarchy
     * (i.e. Number) still matching the required type Number. Therefore, with an
     * arg of type Integer, a constructor (Integer) would be preferred to a
     * constructor (Number) which would in turn be preferred to a constructor
     * (Object). All argument weights get accumulated.
     * 
     * @param paramTypes the parameter types to match
     * @param args the arguments to match
     * @return the accumulated weight for all arguments
     */
    public static int getTypeDifferenceWeight(Class[] paramTypes, Object[] args) {
        int result = 0;
        for (int i = 0; i < paramTypes.length; i++) {
            if (!ClassUtils.isAssignableValue(paramTypes[i], args[i])) {
                return Integer.MAX_VALUE;
            }
            if (args[i] != null) {
                Class paramType = paramTypes[i];
                Class superClass = args[i].getClass().getSuperclass();
                while (superClass != null) {
                    if (paramType.equals(superClass)) {
                        result = result + 2;
                        superClass = null;
                    } else if (ClassUtils.isAssignable(paramType, superClass)) {
                        result = result + 2;
                        superClass = superClass.getSuperclass();
                    } else {
                        superClass = null;
                    }
                }
                if (paramType.isInterface()) {
                    result = result + 1;
                }
            }
        }
        return result;
    }

}
